Auto ScalingさせたEC2インスタンスのIPアドレスを固定しようとするのは止めよう
のっぴきならない事情でAuto ScalingするEC2インスタンスのIPアドレスを固定する必要がある
こんにちは、のんピ(@non____97)です。
皆さんはAuto ScalingさせたEC2インスタンスのIPアドレスを固定したいなと思ったことはありますか? 私はありません。
ここで言うIPアドレスの固定というのは、「このサーバーのIPアドレスは10.10.10.10を割り当てる」のようにサーバーとIPアドレスを1:1で紐づけることを指しています。
個人的にはEC2インスタンスのIPアドレスを固定にするのは好きではありません。単純なコンピューティングリソースとして使い、いわゆる「ペットではなく家畜」として扱うべきだと考えます。
抜粋 : AWS設計のベストプラクティスで最低限知っておくべき10のこと_20200617_rev.pdf
しかし、ファイアウォールでどうしても/24などレンジで開けるのがNGで、必ずホストアドレス(/32)で指定する必要があったり、連携するシステムの都合で事前にホスト名とIPアドレスを1:1でマッピングする必要があったりします。
要するに、Auto Scalingする場合でも以下記事でも紹介したように「のっぴきならない事情でEC2インスタンスに割り当てられるIPアドレスを固定にする必要がある」ということです。
Auto Scaling Groupや起動テンプレートには「指定した範囲のIPアドレスを上から順番に使用する」といった設定はありません。指定されたサブネットの中で自動で割り当てられます。
ただし、事前にセカンダリのENIを作成しておき、EC2インスタンス起動後にENIをアタッチすることで対応できなくはないです。「事前にIPアドレスをプールしておいて、必要にあったら割り当てる」といったイメージです。「プライマリIPアドレスを固定にする」といったことは、先述のとおりAuto Scalingの仕様上できません。
実際に試してみたので紹介します。
いきなりまとめ
- 事前にセカンダリのENIを作成しておき、EC2インスタンス起動後にENIをアタッチすることで対応できなくはない
- 最大キャパシティの数分だけENIを作成する
- 最大キャパシティはAuto Scalingのサブネット数の倍数でなければ、セカンダリENIが割り当てられない可能性がある
- AZの偏りがない場合、どのサブネットにデプロイされるかはランダムであるため、AZ間で一意なホスト名とIPアドレスをマッピングすることはできない
- 送信元IPアドレスを固定にする必要がある場合はMetricを変更して、セカンダリENIのデフォルトルートをプライマリENIのデフォルトルートよりも優先度を高くする必要がある
- IPアドレスを固定する要件があるのであれば、それはAuto Scalingさせるべきではない
- サービスが想定していない使い方で無理やり運用に乗せると、いつか痛い目に合う
- 設定ファイルに自身のIPアドレスを埋め込む必要があるのであれば、ユーザーデータを活用する
やってみる
仕組み
事前にセカンダリのENIを作成しておき、EC2インスタンス起動後にENIをアタッチします。
セカンダリENIの作成部分です。
ENIはAuto Scaling Groupで設定する最大キャパシティの数分だけデプロイします。
セカンダリENIはAuto Scaling Groupで指定されている各サブネットに均等に作成します。セカンダリIPアドレスに割り当てるIPアドレスは各サブネットの先頭から順番に割り当てます。
また、セカンダリENIにはOSのホスト名用のタグHostName
を付与します。ホスト名は<プレフィックス>-<AZ名>-<AZ内での連番>.<ドメイン>
です。
手動で作成することも考えましたが、面倒だったのでAWS CDKで作成しました。実際のコードは以下のとおりです。渡されたCIDRから割り当てるIPアドレスを計算するのが結構大変でした。
// Generate IP Address const generateIpAddress = (cidr: string, index: number): string => { const [networkAddress, mask] = cidr.split("/"); const networkAddressOctets = networkAddress.split(".").map(Number); const maskBit = Number(mask); const availableIpAddressesNumber = Math.pow(2, 32 - maskBit) - 5; const startIpAddressBit = networkAddressOctets.reduce( (accumulator, current, index) => accumulator + (current << ((3 - index) * 8)), 0 ) + 4; if (index < 0 || index >= availableIpAddressesNumber) { return "Error: The provided index is out of range."; } const ipAddressBit = startIpAddressBit + index; const ipAddress = [3, 2, 1, 0] .map((shift) => (ipAddressBit >>> (shift * 8)) % 256) .join("."); return ipAddress; }; // ENI const subnet = props.vpc.selectSubnets({ subnetGroupName: "Public", }).subnets; for (let i = 0; i < maxCapacity; i++) { const subnetId = subnet[i % subnet.length].subnetId; const subnetCidr = subnet[i % subnet.length].ipv4CidrBlock; const availabilityZone = subnet[i % subnet.length].availabilityZone; new cdk.aws_ec2.CfnNetworkInterface(this, `Eni${i}`, { subnetId, groupSet: [this.asg.connections.securityGroups[0].securityGroupId], privateIpAddress: generateIpAddress( subnetCidr, Math.floor(i / subnet.length) ), tags: [ { key: "HostName", value: `${hostname_prefix}-${availabilityZone}-${Math.floor( i / subnet.length )}.${hostname_domain}`, }, ], }); }
続いて、EC2インスタンスにセカンダリENIをアタッチする部分です。
こちらはユーザーデータで以下のような処理を行い、セカンダリENIのアタッチとOSホスト名の設定をします。
- メタデータから必要な情報を取得する
- ループで以下処理を行う
- EC2インスタンスと同じサブネットに属するENIで使用されていないものを、
HostName
タグでソートして先頭のENI IDを取得する - 条件に当てはまるENIが存在しない場合はランダムな秒数sleepしてcontinue
- 取得したENI IDを使用してEC2インスタンスに
DeviceIndex : 1
としてアタッチする - ENIのアタッチが成功した場合はOSホスト名を設定し、ループを抜ける
- ENIのアタッチが失敗した場合はランダムな秒数sleepしてループ継続
- EC2インスタンスと同じサブネットに属するENIで使用されていないものを、
- リトライ回数の上限回数に到達した場合はメッセージを出力する
実際のユーザーデータは以下のとおりです。
#!/bin/bash set -u # Redirect /var/log/user-data.log and /dev/console exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1 declare -r max_retry_interval=8 declare -r max_retries=16 # Get my instance ID token=$(curl \ -s \ -X PUT \ -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" \ "http://169.254.169.254/latest/api/token" ) instance_id=$(curl \ -s \ -H "X-aws-ec2-metadata-token: $token" \ "http://169.254.169.254/latest/meta-data/instance-id" ) # MAC Address mac_address=$(curl \ -s \ -H "X-aws-ec2-metadata-token: $token" \ "http://169.254.169.254/latest/meta-data/mac" ) # Subnet ID region=$(curl \ -s \ -H "X-aws-ec2-metadata-token: $token" \ "http://169.254.169.254/latest/meta-data/placement/region" ) # Subnet ID subnet_id=$(curl \ -s \ -H "X-aws-ec2-metadata-token: $token" \ "http://169.254.169.254/latest/meta-data/network/interfaces/macs/$mac_address/subnet-id" ) for i in $(seq 1 $max_retries); do # Available ENI ID eni_id=$(aws ec2 describe-network-interfaces \ --filters Name=subnet-id,Values=$subnet_id \ Name=status,Values=available \ --query 'sort_by(NetworkInterfaces[], &TagSet[?Key==`HostName`].Value | [0])[].NetworkInterfaceId | [0]' \ --region $region \ --output text ) if [[ $eni_id == 'None' ]]; then retry_interval=$(($RANDOM % $max_retry_interval)) echo "ENI not found, retrying in $retry_interval seconds..." sleep $retry_interval continue fi # Attach ENI aws ec2 attach-network-interface \ --instance-id=$instance_id \ --device-index=1 \ --network-interface-id=$eni_id \ --region $region if [[ $? == 0 ]]; then hostname=$(aws ec2 describe-network-interfaces \ --network-interface-ids $eni_id \ --query "NetworkInterfaces[].TagSet[?Key=='HostName'].Value" \ --region $region \ --output text ) echo "Set HostName ${hostname}" aws ec2 create-tags \ --resources $instance_id \ --tags Key=HostName,Value=$hostname \ --region $region hostnamectl set-hostname "${hostname}" echo "hostnamectl : $(hostnamectl)" break else retry_interval=$(($RANDOM % $max_retry_interval)) echo "Failed to attach ENI, retrying in $retry_interval seconds..." sleep $retry_interval fi done # If the loop exhausted retries, fail and suggest manual assignment if [[ $i == $max_retries ]]; then echo "Failed to allocate a unique hostname after $max_retries retries. Please manually assign a hostname." fi
同時に10台起動してみる
実際に動作確認をしてみます。
動作確認を行う検証環境は全てAWS CDKでデプロイしました。使用したコードは以下リポジトリに保存しています。
構成図は以下のとおりです。4つのAZのサブネットにデプロイします。
セカンダリENIは以下のように作成されます。
AZ | IPアドレス | HostName |
---|---|---|
us-east-1a | 10.10.12.4 | web-us-east-1a-0.corp.non-97.net |
us-east-1a | 10.10.12.5 | web-us-east-1a-1.corp.non-97.net |
us-east-1a | 10.10.12.6 | web-us-east-1a-2.corp.non-97.net |
us-east-1b | 10.10.12.36 | web-us-east-1b-0.corp.non-97.net |
us-east-1b | 10.10.12.37 | web-us-east-1b-1.corp.non-97.net |
us-east-1b | 10.10.12.38 | web-us-east-1b-2.corp.non-97.net |
us-east-1c | 10.10.12.68 | web-us-east-1c-0.corp.non-97.net |
us-east-1c | 10.10.12.69 | web-us-east-1c-1.corp.non-97.net |
us-east-1d | 10.10.12.100 | web-us-east-1d-0.corp.non-97.net |
us-east-1d | 10.10.12.101 | web-us-east-1d-1.corp.non-97.net |
デプロイ後、EC2インスタンスを確認します。
いずれのEC2インスタンスにも、AZに対応したHostName
タグが割り当てられていますね。
ユーザーデータのログを確認してみます。
$ cat /var/log/user-data.log { "AttachmentId": "eni-attach-04903c39cfcbb7012" } Set HostName web-us-east-1a-0.corp.non-97.net hostnamectl : Static hostname: web-us-east-1a-0.corp.non-97.net Icon name: computer-vm Chassis: vm 🖴 Machine ID: ec27f46c24977a2f95ab0bad75b861f7 Boot ID: 38c0b03439ce49098114b4a0e71efece Virtualization: amazon Operating System: Amazon Linux 2023 CPE OS Name: cpe:2.3:o:amazon:amazon_linux:2023 Kernel: Linux 6.1.55-75.123.amzn2023.x86_64 Architecture: x86-64 Hardware Vendor: Amazon EC2 Hardware Model: t3.nano Firmware Version: 1.0
$ cat /var/log/user-data.log An error occurred (InvalidParameterValue) when calling the AttachNetworkInterface operation: Network interface 'eni-0fdd6814816c56fb4' is currently in use. Failed to attach ENI, retrying in 1 seconds... { "AttachmentId": "eni-attach-015cdac966edd76a0" } Set HostName web-us-east-1a-2.corp.non-97.net hostnamectl : Static hostname: web-us-east-1a-2.corp.non-97.net Icon name: computer-vm Chassis: vm 🖴 Machine ID: ec2d7af447e72afb98a650dd40ba034f Boot ID: f5c2306450e7479982b4d3dc2a47848b Virtualization: amazon Operating System: Amazon Linux 2023 CPE OS Name: cpe:2.3:o:amazon:amazon_linux:2023 Kernel: Linux 6.1.55-75.123.amzn2023.x86_64 Architecture: x86-64 Hardware Vendor: Amazon EC2 Hardware Model: t3.nano Firmware Version: 1.0
OS側でもホスト名の設定ができていそうです。また、ENIのアタッチができなかった場合はリトライしていますね。
それでは、各EC2インスタンスに設定されているOSホスト名と各セカンダリENIのIPアドレスを確認します。
AWS CLIの--query
で頑張ってIPアドレスの昇順で出力させます。
aws ec2 describe-instances \ --filter Name=instance-state-name,Values=running \ --query 'sort_by(Reservations[].Instances[].{HostName:Tags[?Key==`HostName`].Value | [0] , SecondaryEniPrivateIpAddress:NetworkInterfaces[?Attachment.DeviceIndex==`1`].PrivateIpAddress[] | [0]},&SecondaryEniPrivateIpAddress)' [ { "HostName": "web-us-east-1d-0.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.100" }, { "HostName": "web-us-east-1d-1.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.101" }, { "HostName": "web-us-east-1b-0.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.36" }, { "HostName": "web-us-east-1b-1.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.37" }, { "HostName": "web-us-east-1b-2.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.38" }, { "HostName": "web-us-east-1a-0.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.4" }, { "HostName": "web-us-east-1a-1.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.5" }, { "HostName": "web-us-east-1a-2.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.6" }, { "HostName": "web-us-east-1c-0.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.68" }, { "HostName": "web-us-east-1c-1.corp.non-97.net", "SecondaryEniPrivateIpAddress": "10.10.12.69" } ]
上手くAZ内でIPアドレスが連番で割り当てられていることが分かります。
なお、sort_by()
は文字としてソートするので10.10.12.100
が10.10.12.36
の前になっています。to_number()
で頑張れば数値としてソートできそうですが気力が尽きました。気になる方はJMESPathのドキュメントを確認しながらチャレンジしてみてください。
最大キャパシティはAuto Scalingのサブネット数の倍数でなければ、セカンダリENIが割り当てられない可能性がある
ということでめでたし。
ではありません。何回か繰り返していると、HostNameタグが割り当てられないEC2インスタンスがありました。
このEC2インスタンスのユーザーデータのログを確認すると、リトライ上限までリトライしていました。
$ cat /var/log/user-data.log An error occurred (InvalidParameterValue) when calling the AttachNetworkInterface operation: Network interface 'eni-0ac07c5954c1f0aea' is currently in use. Failed to attach ENI, retrying in 3 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 6 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 7 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 0 seconds... ENI not found, retrying in 5 seconds... ENI not found, retrying in 0 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 1 seconds... ENI not found, retrying in 3 seconds... ENI not found, retrying in 0 seconds... ENI not found, retrying in 1 seconds... Failed to allocate a unique hostname after 16 retries. Please manually assign a hostname.
原因は同じサブネットの全てのENIが既に割当て済みだからです。
こちらのEC2インスタンスのAZはus-east-1c
です。一方でus-east-1c
にはENIを2つしか作成していません。Auto ScalingはAZ間でバランスをとろうとします。しかし、全てのAZで均等である場合に、どのAZからデプロイするかはランダム...と考えます。
というのも、以下AWS公式ドキュメントには「AZ内のどのサブネットにデプロイするかはランダム」とは記載ありますが、「AZ間で均等が取れている場合、どのAZのサブネットからデプロイするかはランダム」という記載はありませんでした。
インスタンスの分散
Amazon EC2 Auto Scaling は自動的に、有効化された各アベイラビリティーゾーン内で等しい数のインスタンスを維持しようと試みます。Amazon EC2 Auto Scaling は、インスタンス数が最も少ないアベイラビリティーゾーンで新しいインスタンスの起動を試みることによって、これを実行します。アベイラビリティーゾーンに対して複数のサブネットが選択されている場合、Amazon EC2 Auto Scaling はアベイラビリティーゾーンからサブネットをランダムに選択します。この試みが失敗した場合、Amazon EC2 Auto Scaling は成功するまで別のアベイラビリティーゾーンでのインスタンスの起動を試みます。
アベイラビリティーゾーンが異常、または利用不能な状況では、アベイラビリティーゾーン間でのインスタンスの分散が不均等になる可能性があります。アベイラビリティーゾーンが回復すると、Amazon EC2 Auto Scaling は Auto Scaling グループのバランスを自動的に再調整します。これは、インスタンス数が最も少ない有効なアベイラビリティーゾーンでインスタンスを起動し、その他のゾーンでインスタンスを終了することによって行われます。
気になったので実際に動かして確認します。
1台ずつ最小キャパシティを増やしていって、どのAZにEC2インスタンスが作成されるか確認します。
デプロイする環境を改めて確認します。
サブネットは以下のとおりです。PublicSubnet1
がus-east-1a
、PublicSubnet2
がus-east-1b
とそれぞれ昇順で対応しています。
AZ IDは以下のとおりです。「実はAZ IDの順番でデプロイされる」ということがあるのか確認するために見ておきます。
サブネットIDを昇順に並べると以下のとおりです。「サブネットIDの昇順でデプロイされる」ということがあるかもしれないのでチェックしておきます。
それでは1台づつデプロイしていきます。
1台目はus-east-1d
でした。us-east-1a
ではないということからAZ名の昇順でデプロイされるという訳ではないことが分かります。また、us-east-1d
のAZ IDはuse-az4
なので、AZ IDの昇順でデプロイするという訳でもなさそうです。
2台目はus-east-1a
です。ここに来てサブネットIDの昇順でデプロイされる説が出てきました。
3台目はus-east-1b
です。us-east-1b
のサブネットのIDは昇順でソートした時に4つある内の4つ目です。ということでサブネットIDの昇順でデプロイされるということではなさそうです。
全台削除した時に、どのAZから作成されるのかも確認します。
us-east-1b
でした。最初に1台目をデプロイした時のAZと異なるので、全てのAZで均等である場合に、どのAZからデプロイするかは確かにランダムでありそうです。
以上を鑑みると、「最大キャパシティはAuto Scalingのサブネット数の倍数でなければ、セカンダリENIが割り当てられない可能性がある」となります。
AZ間でもOSホスト名も連番にするには?
紹介した例では、OSホスト名がAZ内で連番となっていました。
では、AZ間でもOSホスト名も連番にするにはどうしたら良いでしょうか。
結論としては無理です。
以下のように問題が生じます。
- 意図したとおり連番とならない可能性が高い
- 仮に連番となったとしても、AZ障害で特定のAZにデプロイできなくなると、連番でなくなる。
「意図したとおり連番とならない可能性が高い」ですが、こちらは先述のとおり、全てのAZで均等である場合に、どのAZからデプロイするかはランダムであるためです。
us-east-1a
に1号機用のENIを用意したとしても、1台目がus-east-1b
からデプロイされる可能があります。
仮に運よく順番にデプロイされたとしても、AZ障害でそのAZ内にEC2インスタンスがデプロイされなくなるのであれば、そのENI = IPアドレスを割り当てることはできません。ホスト名もこのユーザーデータを使って割り当てるのであれば連番ではなくなってしまいます。
「じゃあセカンダリENIを1つのサブネットに集約すれば良いのでは?」と思われるかもしれませんが、それも基本的にできないと考えるのが良いでしょう。
前提としてEC2インスタンスは同じAZのENIしかアタッチできません。そのため、Single-AZのAuto Scalingでしか実現できません。AZ障害を許容する形となります。
もし、これでも連番としたい場合、そこまでして実装したいものとは何でしょうか。それはもはやAuto Scalingを使うべき環境ではないと思います。
セカンダリENIを割り当てた場合の送信元IPアドレスは?
それでは、セカンダリENIを割り当てた場合の送信元IPアドレスはどうなるでしょう。
答えとしては、基本的にプライマリENIとなります。
以下のようにENI毎にルートが設定されています。
$ route -n Kernel IP routing table Destination Gateway Genmask Flags Metric Ref Use Iface 0.0.0.0 10.10.12.1 0.0.0.0 UG 512 0 0 ens5 0.0.0.0 10.10.12.1 0.0.0.0 UG 522 0 0 ens6 10.10.12.0 0.0.0.0 255.255.255.224 U 512 0 0 ens5 10.10.12.0 0.0.0.0 255.255.255.224 U 522 0 0 ens6 10.10.12.1 0.0.0.0 255.255.255.255 UH 512 0 0 ens5 10.10.12.1 0.0.0.0 255.255.255.255 UH 522 0 0 ens6 10.10.12.2 0.0.0.0 255.255.255.255 UH 512 0 0 ens5 10.10.12.2 0.0.0.0 255.255.255.255 UH 522 0 0 ens6 $ ip a 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host noprefixroute valid_lft forever preferred_lft forever 2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000 link/ether 0e:1a:ea:ab:78:89 brd ff:ff:ff:ff:ff:ff altname enp0s5 altname eni-0f95d959b365fccb0 altname device-number-0 inet 10.10.12.13/27 metric 512 brd 10.10.12.31 scope global dynamic ens5 valid_lft 3533sec preferred_lft 3533sec inet6 fe80::c1a:eaff:feab:7889/64 scope link valid_lft forever preferred_lft forever 3: ens6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000 link/ether 0e:05:2b:84:1d:89 brd ff:ff:ff:ff:ff:ff altname enp0s6 altname eni-0fdd6814816c56fb4 altname device-number-1 inet 10.10.12.4/27 metric 522 brd 10.10.12.31 scope global dynamic ens6 valid_lft 3547sec preferred_lft 3547sec inet6 fe80::c05:2bff:fe84:1d89/64 scope link valid_lft forever preferred_lft forever
Destination
が重複する場合はMetric
の値が小さい方が優先されます。今回の場合ens5
とens6
のいずれのDestination
も重複しておりMetric
はens5
の方が小さいため、ens5
であるプライマリENIからトラフィックは流れます。
もし、セカンダリENIのIPアドレスから送信したい場合は、以下2つの対応があると考えます。
- 通信するときに送信元のインターフェイスやIPアドレスを指定する
- ユーザーデータの中でENIアタッチ後、セカンダリENIのMetricの方を小さくする処理を行う
どちらの場合も都度指定するのが面倒 or 一筋縄ではできなさそうです。
無理やりできないことはないけど、本当にそれを実現する必要があるのか
Auto ScalingさせたEC2インスタンスのIPアドレスを固定させてみました。
結論としては、Auto ScalingするEC2インスタンスでも無理やりできないことはないですが、止めた方が良いです。
IPアドレスの固定はAuto Scalingというサービスが想定していない使い方です。無理やり運用に乗せると運用にしわ寄せが行き、いつか痛い目に合うでしょう。
運用負荷軽減のためにも、シンプルな運用を目指してオーバーエンジニアリングしないことがベターです。
そのため、まずは「本当にEC2インスタンスのIPアドレスを固定にする必要があるのか」を考えましょう。もし、それを実現する必要があるのであれば、少なくともEC2インスタンスAuto Scalingさせるべきではないと考えます。
ちなみに、自身のIPアドレスを何かしらの設定ファイルに埋め込む必要があるのであれば、その設定ファイルにIPアドレスを指定するためのプレースホルダーを記載しておいて、ユーザーデータで置換してあげることで対応できるでしょう。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!